/*
  Copyright 2020 the JSDoc Authors.

  Licensed under the Apache License, Version 2.0 (the "License");
  you may not use this file except in compliance with the License.
  You may obtain a copy of the License at

      https://www.apache.org/licenses/LICENSE-2.0

  Unless required by applicable law or agreed to in writing, software
  distributed under the License is distributed on an "AS IS" BASIS,
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  See the License for the specific language governing permissions and
  limitations under the License.
*/

import babelParser from '@babel/parser';

import { parserOptions } from '../../../lib/ast-builder.js';
import * as astNode from '../../../lib/ast-node.js';
import { Syntax } from '../../../lib/syntax.js';

describe('@jsdoc/ast/lib/ast-node', () => {
  function parse(str) {
    return babelParser.parse(str, parserOptions).program.body[0];
  }

  // Create the AST nodes we'll be testing.
  const arrayExpression = parse('[,]').expression;
  const arrowFunctionExpression = parse('var foo = () => {};').declarations[0].init;
  const assignmentExpression = parse('foo = 1;').expression;
  const binaryExpression = parse('foo & foo;').expression;
  const experimentalObjectRestSpread = parse('var one = {...two, three: 4};').declarations[0].init;
  const functionDeclaration1 = parse('function foo() {}');
  const functionDeclaration2 = parse('function foo(bar) {}');
  const functionDeclaration3 = parse('function foo(bar, baz, qux) {}');
  const functionDeclaration4 = parse('function foo(...bar) {}');
  const functionExpression1 = parse('var foo = function() {};').declarations[0].init;
  const functionExpression2 = parse('var foo = function(bar) {};').declarations[0].init;
  const identifier = parse('foo;').expression;
  const literal = parse('1;').expression;
  const memberExpression = parse('foo.bar;').expression;
  const memberExpressionComputed1 = parse('foo["bar"];').expression;
  const memberExpressionComputed2 = parse("foo['bar'];").expression;
  const methodDefinition1 = parse('class Foo { bar() {} }').body.body[0];
  const methodDefinition2 = parse('var foo = () => class { bar() {} };').declarations[0].init.body
    .body[0];
  const privateName = parse('class MyClass { #myPrivateMethod() {} }').body.body[0].key;
  const propertyGet = parse('var foo = { get bar() {} };').declarations[0].init.properties[0];
  const propertyInit = parse('var foo = { bar: {} };').declarations[0].init.properties[0];
  const propertySet = parse('var foo = { set bar(a) {} };').declarations[0].init.properties[0];
  const thisExpression = parse('this;').expression;
  const unaryExpression1 = parse('+1;').expression;
  const unaryExpression2 = parse('+foo;').expression;
  const variableDeclarator1 = parse('var foo = 1;').declarations[0];
  const variableDeclarator2 = parse('var foo;').declarations[0];

  // Create AST nodes that we can use to autodetect the module type.
  const amdDefine = parse('define("foo", ["node:fs"], function (fs) { fs; });').expression;
  const amdDriverScript = parse('require(["foo"], function (foo) {});').expression;
  const commonJsExports = parse('exports.foo = () => {};').expression;
  const commonJsRequire = parse('const fs = require("node:fs");').declarations[0].init;
  const es6Export = parse('export function foo() {}');
  const es6Import = parse('import { readFile } from "node:fs/promises";');

  it('should exist', () => {
    expect(astNode).toBeObject();
  });

  it('should export an addNodeProperties method', () => {
    expect(astNode.addNodeProperties).toBeFunction();
  });

  it('exports a detectModuleType method', () => {
    expect(astNode.detectModuleType).toBeFunction();
  });

  it('should export a getInfo method', () => {
    expect(astNode.getInfo).toBeFunction();
  });

  it('should export a getParamNames method', () => {
    expect(astNode.getParamNames).toBeFunction();
  });

  it('should export an isAccessor method', () => {
    expect(astNode.isAccessor).toBeFunction();
  });

  it('should export an isAssignment method', () => {
    expect(astNode.isAssignment).toBeFunction();
  });

  it('should export an isFunction method', () => {
    expect(astNode.isFunction).toBeFunction();
  });

  it('should export an isScope method', () => {
    expect(astNode.isScope).toBeFunction();
  });

  it('exports a MODULE_TYPES enum', () => {
    expect(astNode.MODULE_TYPES).toBeObject();
  });

  it('should export a nodeToString method', () => {
    expect(astNode.nodeToString).toBeFunction();
  });

  it('should export a nodeToValue method', () => {
    expect(astNode.nodeToValue).toBeFunction();
  });

  describe('addNodeProperties', () => {
    it('returns `null` for undefined input', () => {
      expect(astNode.addNodeProperties()).toBe(null);
    });

    it('sets the `nodeId`', () => {
      const node = astNode.addNodeProperties({});
      const descriptor = Object.getOwnPropertyDescriptor(node, 'nodeId');

      expect(descriptor).toBeObject();
      expect(descriptor.value).toBeString();
      expect(descriptor.enumerable).toBeTrue();
    });

    it('sets a non-enumerable, writable `parent`', () => {
      const node = astNode.addNodeProperties({});
      const descriptor = Object.getOwnPropertyDescriptor(node, 'parent');

      expect(descriptor).toBeDefined();
      expect(descriptor.value).toBeUndefined();
      expect(descriptor.enumerable).toBeFalse();
      expect(descriptor.writable).toBeTrue();
    });

    it('sets an enumerable `parentId`', () => {
      const node = astNode.addNodeProperties({});
      const descriptor = Object.getOwnPropertyDescriptor(node, 'parentId');

      expect(descriptor).toBeObject();
      expect(descriptor.enumerable).toBeTrue();
    });

    it('sets `parentId` to `null` for nodes with no parent', () => {
      const node = astNode.addNodeProperties({});

      expect(node.parentId).toBeNull();
    });

    it('sets a non-null `parentId` for nodes with a parent', () => {
      const node = astNode.addNodeProperties({});
      const parent = astNode.addNodeProperties({});

      node.parent = parent;

      expect(node.parentId).toBe(parent.nodeId);
    });

    it('sets a non-enumerable, writable `enclosingScope`', () => {
      const node = astNode.addNodeProperties({});
      const descriptor = Object.getOwnPropertyDescriptor(node, 'enclosingScope');

      expect(descriptor).toBeObject();
      expect(descriptor.value).toBeUndefined();
      expect(descriptor.enumerable).toBeFalse();
      expect(descriptor.writable).toBeTrue();
    });

    it('sets an enumerable `enclosingScopeId`', () => {
      const node = astNode.addNodeProperties({});
      const descriptor = Object.getOwnPropertyDescriptor(node, 'enclosingScopeId');

      expect(descriptor).toBeObject();
      expect(descriptor.enumerable).toBeTrue();
    });

    it('sets `enclosingScopeId` to `null` for nodes with no enclosing scope', () => {
      const node = astNode.addNodeProperties({});

      expect(node.enclosingScopeId).toBeNull();
    });

    it('sets a non-null `enclosingScopeId` for nodes with an enclosing scope', () => {
      const node = astNode.addNodeProperties({});
      const enclosingScope = astNode.addNodeProperties({});

      node.enclosingScope = enclosingScope;

      expect(node.enclosingScopeId).toBe(enclosingScope.nodeId);
    });
  });

  describe('detectModuleType', () => {
    const { detectModuleType, MODULE_TYPES } = astNode;

    it('returns `null` if the module type cannot be inferred from the node', () => {
      expect(detectModuleType(identifier)).toBeNull();
    });

    it('detects AMD modules that use `define()`', () => {
      expect(detectModuleType(amdDefine)).toBe(MODULE_TYPES.AMD);
    });

    it('detects AMD driver scripts', () => {
      expect(detectModuleType(amdDriverScript)).toBe(MODULE_TYPES.AMD);
    });

    it('detects CommonJS modules that assign to `exports`', () => {
      expect(detectModuleType(commonJsExports)).toBe(MODULE_TYPES.COMMON_JS);
    });

    it('detects CommonJS modules that call `require()`', () => {
      expect(detectModuleType(commonJsRequire)).toBe(MODULE_TYPES.COMMON_JS);
    });

    it('detects ES6 modules that use `export`', () => {
      expect(detectModuleType(es6Export)).toBe(MODULE_TYPES.ES6);
    });

    it('detects ES6 modules that use `import`', () => {
      expect(detectModuleType(es6Import)).toBe(MODULE_TYPES.ES6);
    });
  });

  describe('getInfo', () => {
    it('should throw an error for undefined input', () => {
      function noNode() {
        astNode.getInfo();
      }

      expect(noNode).toThrow();
    });

    it('should return the correct info for an AssignmentExpression', () => {
      const info = astNode.getInfo(assignmentExpression);

      expect(info).toBeObject();

      expect(info.node).toBeObject();
      expect(info.node.type).toBe(Syntax.Literal);
      expect(info.node.value).toBe(1);

      expect(info.name).toBe('foo');
      expect(info.type).toBe(Syntax.Literal);
      expect(info.value).toBe(1);
    });

    it('should return the correct info for a FunctionDeclaration', () => {
      const info = astNode.getInfo(functionDeclaration2);

      expect(info).toBeObject();

      expect(info.node).toBeObject();
      expect(info.node.type).toBe(Syntax.FunctionDeclaration);

      expect(info.name).toBe('foo');
      expect(info.type).toBe(Syntax.FunctionDeclaration);
      expect(info.value).toBeUndefined();

      expect(info.paramnames).toBeArrayOfSize(1);
      expect(info.paramnames[0]).toBe('bar');
    });

    it('should return the correct info for a FunctionExpression', () => {
      const info = astNode.getInfo(functionExpression2);

      expect(info).toBeObject();

      expect(info.node).toBeObject();
      expect(info.node.type).toBe(Syntax.FunctionExpression);

      expect(info.name).toBe('');
      expect(info.type).toBe(Syntax.FunctionExpression);
      expect(info.value).toBeUndefined();

      expect(info.paramnames).toBeArrayOfSize(1);
      expect(info.paramnames[0]).toBe('bar');
    });

    it('should return the correct info for a MemberExpression', () => {
      const info = astNode.getInfo(memberExpression);

      expect(info).toBeObject();

      expect(info.node).toBeObject();
      expect(info.node.type).toBe(Syntax.MemberExpression);

      expect(info.name).toBe('foo.bar');
      expect(info.type).toBe(Syntax.MemberExpression);
    });

    it('should return the correct info for a computed MemberExpression', () => {
      const info = astNode.getInfo(memberExpressionComputed1);

      expect(info).toBeObject();

      expect(info.node).toBeObject();
      expect(info.node.type).toBe(Syntax.MemberExpression);

      expect(info.name).toBe('foo["bar"]');
      expect(info.type).toBe(Syntax.MemberExpression);
    });

    it('should return the correct info for a Property initializer', () => {
      const info = astNode.getInfo(propertyInit);

      expect(info).toBeObject();

      expect(info.node).toBeObject();
      expect(info.node.type).toBe(Syntax.ObjectExpression);

      expect(info.name).toBe('bar');
      expect(info.type).toBe(Syntax.ObjectExpression);
    });

    it('should return the correct info for a Property setter', () => {
      const info = astNode.getInfo(propertySet);

      expect(info).toBeObject();

      expect(info.node).toBeObject();
      expect(info.node.type).toBe(Syntax.FunctionExpression);

      expect(info.name).toBe('bar');
      expect(info.type).toBeUndefined();
      expect(info.value).toBeUndefined();

      expect(info.paramnames).toBeArrayOfSize(1);
      expect(info.paramnames[0]).toBe('a');
    });

    it('should return the correct info for a VariableDeclarator with a value', () => {
      const info = astNode.getInfo(variableDeclarator1);

      expect(info).toBeObject();

      expect(info.node).toBeObject();
      expect(info.node.type).toBe(Syntax.Literal);

      expect(info.name).toBe('foo');
      expect(info.type).toBe(Syntax.Literal);
      expect(info.value).toBe(1);
    });

    it('should return the correct info for a VariableDeclarator with no value', () => {
      const info = astNode.getInfo(variableDeclarator2);

      expect(info).toBeObject();

      expect(info.node).toBeObject();
      expect(info.node.type).toBe(Syntax.Identifier);

      expect(info.name).toBe('foo');
      expect(info.type).toBeUndefined();
      expect(info.value).toBeUndefined();
    });

    it('should return the correct info for other node types', () => {
      const info = astNode.getInfo(binaryExpression);

      expect(info).toBeObject();

      expect(info.node).toBe(binaryExpression);
      expect(info.type).toBe(Syntax.BinaryExpression);
    });
  });

  describe('getParamNames', () => {
    it('should return an empty array for undefined input', () => {
      const params = astNode.getParamNames();

      expect(params).toBeEmptyArray();
    });

    it('should return an empty array if the input has no params property', () => {
      const params = astNode.getParamNames({});

      expect(params).toBeEmptyArray();
    });

    it('should return an empty array if the input has no params', () => {
      const params = astNode.getParamNames(functionDeclaration1);

      expect(params).toBeEmptyArray();
    });

    it('should return a single-item array if the input has a single param', () => {
      const params = astNode.getParamNames(functionDeclaration2);

      expect(params).toEqual(['bar']);
    });

    it('should return a multi-item array if the input has multiple params', () => {
      const params = astNode.getParamNames(functionDeclaration3);

      expect(params).toEqual(['bar', 'baz', 'qux']);
    });

    it('should include rest parameters', () => {
      const params = astNode.getParamNames(functionDeclaration4);

      expect(params).toEqual(['bar']);
    });
  });

  describe('isAccessor', () => {
    it('should return false for undefined values', () => {
      expect(astNode.isAccessor()).toBeFalse();
    });

    it('should return false if the parameter is not an object', () => {
      expect(astNode.isAccessor('foo')).toBeFalse();
    });

    it('should return false for non-Property nodes', () => {
      expect(astNode.isAccessor(binaryExpression)).toBeFalse();
    });

    it('should return false for Property nodes whose kind is "init"', () => {
      expect(astNode.isAccessor(propertyInit)).toBeFalse();
    });

    it('should return true for Property nodes whose kind is "get"', () => {
      expect(astNode.isAccessor(propertyGet)).toBeTrue();
    });

    it('should return true for Property nodes whose kind is "set"', () => {
      expect(astNode.isAccessor(propertySet)).toBeTrue();
    });
  });

  describe('isAssignment', () => {
    it('should return false for undefined values', () => {
      expect(astNode.isAssignment()).toBeFalse();
    });

    it('should return false if the parameter is not an object', () => {
      expect(astNode.isAssignment('foo')).toBeFalse();
    });

    it('should return false for nodes that are not assignments', () => {
      expect(astNode.isAssignment(binaryExpression)).toBeFalse();
    });

    it('should return true for AssignmentExpression nodes', () => {
      expect(astNode.isAssignment(assignmentExpression)).toBeTrue();
    });

    it('should return true for VariableDeclarator nodes', () => {
      expect(astNode.isAssignment(variableDeclarator1)).toBeTrue();
    });
  });

  describe('isFunction', () => {
    it('should recognize function declarations as functions', () => {
      expect(astNode.isFunction(functionDeclaration1)).toBeTrue();
    });

    it('should recognize function expressions as functions', () => {
      expect(astNode.isFunction(functionExpression1)).toBeTrue();
    });

    it('should recognize method definitions as functions', () => {
      expect(astNode.isFunction(methodDefinition1)).toBeTrue();
    });

    it('should recognize arrow function expressions as functions', () => {
      expect(astNode.isFunction(arrowFunctionExpression)).toBeTrue();
    });

    it('should recognize non-functions', () => {
      expect(astNode.isFunction(arrayExpression)).toBeFalse();
    });
  });

  describe('isScope', () => {
    it('should return false for undefined values', () => {
      expect(astNode.isScope()).toBeFalse();
    });

    it('should return false if the parameter is not an object', () => {
      expect(astNode.isScope('foo')).toBeFalse();
    });

    it('should return true for CatchClause nodes', () => {
      expect(astNode.isScope({ type: Syntax.CatchClause })).toBeTrue();
    });

    it('should return true for FunctionDeclaration nodes', () => {
      expect(astNode.isScope({ type: Syntax.FunctionDeclaration })).toBeTrue();
    });

    it('should return true for FunctionExpression nodes', () => {
      expect(astNode.isScope({ type: Syntax.FunctionExpression })).toBeTrue();
    });

    it('should return false for other nodes', () => {
      expect(astNode.isScope({ type: Syntax.NameExpression })).toBeFalse();
    });
  });

  describe('nodeToString', () => {
    it('should be an alias to nodeToValue', () => {
      expect(astNode.nodeToString).toBe(astNode.nodeToValue);
    });
  });

  describe('nodeToValue', () => {
    it('should return `[null]` for the sparse array `[,]`', () => {
      expect(astNode.nodeToValue(arrayExpression)).toBe('[null]');
    });

    it('should return the variable name for assignment expressions', () => {
      expect(astNode.nodeToValue(assignmentExpression)).toBe('foo');
    });

    it('should return the function name for function declarations', () => {
      expect(astNode.nodeToValue(functionDeclaration1)).toBe('foo');
    });

    it('should return undefined for anonymous function expressions', () => {
      expect(astNode.nodeToValue(functionExpression1)).toBeUndefined();
    });

    it('should return the identifier name for identifiers', () => {
      expect(astNode.nodeToValue(identifier)).toBe('foo');
    });

    it('should return the literal value for literals', () => {
      expect(astNode.nodeToValue(literal)).toBe(1);
    });

    it('should return the object and property for noncomputed member expressions', () => {
      expect(astNode.nodeToValue(memberExpression)).toBe('foo.bar');
    });

    it(
      'should return the object and property, with a computed property that uses the same ' +
        'quote character as the original source, for computed member expressions',
      () => {
        expect(astNode.nodeToValue(memberExpressionComputed1)).toBe('foo["bar"]');
        expect(astNode.nodeToValue(memberExpressionComputed2)).toBe("foo['bar']");
      }
    );

    // TODO: we can't test this here because JSDoc, not Babylon, adds the `parent` property to
    // nodes. also, we currently return an empty string instead of `<anonymous>` in this case;
    // see `module:@jsdoc/ast.astNode.nodeToValue` and the comment on
    // `Syntax.MethodDefinition` for details
    xit(
      'should return `<anonymous>` for method definitions inside classes that were ' +
        'returned by an arrow function expression',
      () => {
        expect(astNode.nodeToValue(methodDefinition2)).toBe('<anonymous>');
      }
    );

    it('returns the name, including the `#` prefix, for private names', () => {
      expect(astNode.nodeToValue(privateName)).toBe('#myPrivateMethod');
    });

    it('should return "this" for this expressions', () => {
      expect(astNode.nodeToValue(thisExpression)).toBe('this');
    });

    it('should return the operator and nodeToValue value for prefix unary expressions', () => {
      expect(astNode.nodeToValue(unaryExpression1)).toBe('+1');
      expect(astNode.nodeToValue(unaryExpression2)).toBe('+foo');
    });

    it('should throw an error for postfix unary expressions', () => {
      function postfixNodeToValue() {
        // there's no valid source representation for this one, so we fake it
        const unaryExpressionPostfix = (() => {
          const node = parse('+1;').body[0].expression;

          node.prefix = false;

          return node;
        })();

        return astNode.nodeToValue(unaryExpressionPostfix);
      }

      expect(postfixNodeToValue).toThrow();
    });

    it('should return the variable name for variable declarators', () => {
      expect(astNode.nodeToValue(variableDeclarator1)).toBe('foo');
    });

    it('should return an empty string for all other nodes', () => {
      expect(astNode.nodeToValue(binaryExpression)).toBe('');
    });

    it('should understand and ignore ExperimentalSpreadProperty', () => {
      expect(astNode.nodeToValue(experimentalObjectRestSpread)).toBe('{"three":4}');
    });
  });
});
